Identify CommandForm fields and columns by a tamper-resistant marker - #112
Identify CommandForm fields and columns by a tamper-resistant marker#112woksin wants to merge 6 commits into
Conversation
The suite runs with `isolate: false`, so a module imported by one spec file stays in the registry with that file's mocks bound in, and vitest's file order is not stable between runs. Eight specs imported the module under test statically and therefore passed or failed depending on which file happened to load it first. Measured on an unmodified tree: three of six consecutive `yarn test` runs were red, failing in `Dialogs/for_Dialog/when_confirming_with_close_dialog_and_result`, `for_CommandDialog/when_confirming_with_close_dialog_and_command_result` or `for_toastCommandResult/when_toasting_a_command_result`. A separate symptom of the same cause was an unhandled `commandInstance.execute is not a function` rejection, raised when a CommandDialog bound to another file's auto-firing Button mock reached a spec whose `useCommandInstance` returns no `execute`. That failed `yarn ci` with every test still reporting green. Each affected spec now re-evaluates the module under test inside `beforeEach` after `vi.resetModules()`, which is the idiom `when_validity_is_gated` and `when_step_has_field_errors` already used — those two never appeared in any failure. Ten consecutive runs are now clean with no unhandled errors. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DRCRgRvMz9N8P8NGwR34MM
…layName A `CommandForm` child was classified as a field or a column by exactly one test: `component.displayName === 'CommandFormField'` (or `'CommandFormColumn'`). `displayName` is React's public, writable diagnostic name and a routine target for build tooling, so any transform that sets it unbinds every field — with no error, no warning and every gate green. The field then renders with no container: no label, no bound value, no change handler. This adds a marker that such a transform cannot reach, checked first, with the `displayName` comparison kept as a fallback: - `CommandFormFieldMarker` / `CommandFormColumnMarker` — `Symbol.for` registry keys, so `@cratis/arc.react` and `@cratis/components` resolve the same symbol without importing it from each other. A named import would be a hard module-link error against any version in this package's peer range that does not export it, and a plain `Symbol()` would give a duplicate install two keys that never compare equal. - `isCommandFormField` / `isCommandFormColumn` — marker first, `displayName` second — now used at all three read sites (`CommandDialog`, and both reads in `CommandStepper`). - `markAsCommandFormField` / `markAsCommandFormColumn` set the marker *and* the legacy `displayName`; `CommandDialog.Column` is stamped through the latter. The `displayName` path is retained indefinitely rather than deprecated. It is what lets these two independently versioned packages interoperate in both directions, and what keeps working every consumer who marks a field by hand. Removing it would reproduce the very failure this change prevents. Purely additive: no public API is removed and no existing consumer changes behaviour. This is the consumer half of the contract. The field marker only takes effect once `@cratis/arc.react` stamps it; until then every path here falls back to `displayName` exactly as before. Because both sides keep the fallback, the two packages may ship in either order without a skew hazard. Also documents that `displayName` is load-bearing on field and column components, including the Storybook `reactDocgen: 'react-docgen-typescript'` default that rewrites it and the `setDisplayName: false` setting that disables it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DRCRgRvMz9N8P8NGwR34MM
The first pass proved the marker path and left gaps around it. Adds:
- a component carrying *only* the marker and no displayName at all — the mirror
of the legacy-only case, and the one that shows the marker is sufficient by
itself rather than merely corroborating the label;
- what the marking helpers actually do — both identifiers set, no cross-marking,
and the component returned is the one that was marked, since both call styles
are used in this package;
- strictness of the check: a marker of `false`, or of a truthy non-boolean, is
not a marker;
- a renamed field nested inside `CommandDialog.Column`, which reaches the field
through `processChildren`' recursion rather than as a direct child — the
arrangement the column API exists for;
- `Symbol.keyFor` assertions on marker identity. That distinguishes
`Symbol.for('x')` from `Symbol('x')`, which nothing else about the value does,
and names the key the other package has to use. Those key strings are the
whole cross-package contract: changing one breaks it while every exported
identifier stays the same.
Both directions are now mutation-proven. Reverting the predicates to
legacy-string-only reds 11 tests across 7 files; removing the legacy fallback
instead reds 7 across 5 — including the pre-existing
`when_step_has_field_errors`, which stamps the string on a fake to make it a
field and is exactly the canary for that breaking change.
Also documents that the helpers replace any existing `displayName`, which is
forced rather than incidental — an older Arc binds by that exact string — so a
component needing its own diagnostic label cannot also be marked.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DRCRgRvMz9N8P8NGwR34MM
The marker makes the right thing possible; this makes the wrong thing visible.
Consumers hand-roll `CommandForm` fields, and the failure mode being fixed is
silent — a renamed component simply stops being a field, with no error, no
warning and every gate green — so a lint rule is the only place it surfaces at
authoring time.
Flags identifying a field or column by a hand-written `displayName` string in
either direction: stamping it (assignment, computed access, or object-literal
form as in `Object.assign`) and comparing against it (`===`/`!==`, either
operand order, including the `(x as { displayName?: string })` cast form this
package itself used). Points at `markAsCommandFormField`/`markAsCommandFormColumn`
and `isCommandFormField`/`isCommandFormColumn`, naming the right helper for the
string that was written.
Referring to the exported `CommandFormFieldDisplayName` /
`CommandFormColumnDisplayName` constants is not flagged, so the declarations
themselves and any deliberate legacy-path code stay clean. Going through the
helpers is strictly more permissive than the literal, never less — they still
set and honour the legacy `displayName` — so the rule never trades compatibility
for safety.
Verified end to end through the ESLint Linter, not only RuleTester: all three
shapes this repo carried before the marker existed are reported, each naming the
correct helper. 21 rule tests added.
This repo's own eslint config does not load the plugin, so this changes no gate
here; it is published surface for consumers and is enabled in
`configs.recommended` alongside the existing four rules.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DRCRgRvMz9N8P8NGwR34MM
Two rules under .ai/rules were missed when this work was written.
American English (general.md, typescript.md "Language — American English
Only"): "recognise"/"recognised" become "recognize"/"recognized" across the
marker module, its specs, the ESLint rule and both docs pages. `Cancelled` is
left alone — that is the spelling of Arc's DialogResult member, an API name
rather than prose.
Spaces in it() descriptions (specs.typescript.md "Naming Conventions", where
it('should_return_invalid_result') is the explicit counter-example): every
it() in the new specs becomes a readable sentence. The pre-existing underscore
descriptions elsewhere are left untouched — they predate this work, and the
repo already runs 157 space-style descriptions against 57 underscore ones, so
the convention followed here is also the majority one.
No behavior change; identifiers, assertions and control flow are untouched.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DRCRgRvMz9N8P8NGwR34MM
@cratis/arc.react marks fields and columns with `isCommandFormField` and `isCommandFormColumn` boolean properties. This package had reached for `Symbol.for` registry keys instead, and the two markers cannot see each other. Nothing threw, because both sides kept the legacy `displayName` fallback — but that is what hid the defect. The marker did nothing across the package boundary, so a field whose `displayName` a build transform had rewritten still bound in a bare `CommandForm` and silently unbound inside a `CommandDialog` or `CommandStepper`: the exact failure the marker was added to prevent, surviving the fix, with every spec in both packages passing. Arc's shape wins because arc owns the contract — it defines `asCommandFormField` and `CommandForm` — and because the argument for the Symbol does not hold up. A plain property needs no cross-package import either, since either side can test `isCommandFormField === true` locally, so it gives up none of the version decoupling; and no build transform renames arbitrary static properties, only `displayName`, which is the whole hazard. `CommandFormMarked` is duplicated here rather than imported: the peer range on @cratis/arc.react spans versions that do not export it, so a named import would be a hard module-link error rather than a graceful degrade. Adds the spec neither package had — a component marked the way arc marks one, with its displayName then overwritten, is recognized here; and one marked here carries the exact property names arc reads. Renaming either marker now reds that spec, where before it changed nothing observable in either repo. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DRCRgRvMz9N8P8NGwR34MM
|
Reviewer context — none of this is release-note material. Marker shape, and why it changed mid-flight. The marker is
The Arc's half. Arc stamps the field marker at all four sites (two of which, Verification. One internal fix is bundled (first commit, separable): eight dialog, stepper and toast specs no longer depend on spec file execution order. The suite runs with Open for a maintainer: whether a dev-build No issue references in the description: the repository's issues could not be searched from this environment, and inventing or reusing a number is worse than omitting one. Happy to add them. |
|
Pairs with Cratis/Arc#2443, which carries the other half of the marker contract. Either can merge first — both sides keep the legacy |
Summary
CommandFormfields and columns are now identified by a marker a build transform cannot rewrite, with the legacydisplayNamekept as a permanent fallback.Added
markAsCommandFormFieldandmarkAsCommandFormColumnfor marking a hand-rolled field or column, andisCommandFormFieldandisCommandFormColumnfor identifying one, exported from@cratis/components/CommandFormCommandFormMarked, the marker shape shared with@cratis/arc.react, and theCommandFormFieldDisplayNameandCommandFormColumnDisplayNameconstantsno-raw-command-form-markerrule in@cratis/eslint-plugin-components, flagging fields and columns identified by a hand-writtendisplayNamestringCommandFormchild is recognized as a field, and on the Storybook setting that breaks itChanged
CommandDialog,CommandStepperandCommandDialog.Columnidentify children through the marker, falling back todisplayName, so consumers marking a field by hand are unaffectedFixed
CommandFormfield whosedisplayNameis rewritten by a build transform — such as Storybook'sreactDocgen: 'react-docgen-typescript', which rewrites it by default — is no longer silently unbound and rendered without its label, value binding and change handler